5.16. Справочник по Ассемблеру
Справочник по Ассемблеру
Архитектурные основы и контекст
Что такое язык ассемблера
Язык ассемблера — это низкоуровневый язык программирования, предназначенный для прямого управления аппаратными ресурсами процессора. Каждая инструкция ассемблера соответствует одной машинной команде целевой архитектуры. Язык ассемблера обеспечивает полный контроль над регистрами, памятью, флагами и выполнением программы.
Целевые архитектуры
Существует множество архитектур процессоров, каждая из которых имеет собственный набор инструкций (ISA — Instruction Set Architecture). Наиболее распространённые архитектуры, для которых пишут на ассемблере:
- x86 — 32-битная архитектура, разработанная Intel, используется в персональных компьютерах.
- x86-64 (AMD64 / Intel 64) — 64-битное расширение x86, доминирующая архитектура настольных и серверных систем.
- ARM — энергоэффективная архитектура, применяемая в мобильных устройствах, встраиваемых системах и современных ноутбуках.
- MIPS — учебная и встраиваемая архитектура, часто используется в образовательных целях.
- RISC-V — открытая архитектура с модульным набором инструкций, набирающая популярность в академической и промышленной среде.
Данный справочник ориентирован преимущественно на x86-64, как наиболее актуальную архитектуру для обучения и практического применения на стандартных ПК.
Регистры процессора
Регистры — это сверхбыстрые ячейки памяти внутри процессора, используемые для хранения операндов, адресов и промежуточных результатов вычислений.
Основные регистры общего назначения (в x86-64)
| Регистр | Размер | Назначение |
|---|---|---|
RAX | 64 бит | Аккумулятор, используется в арифметических операциях и возврате значений функций |
RBX | 64 бит | Базовый регистр, часто используется для хранения базовых адресов |
RCX | 64 бит | Счётчик, применяется в циклах и сдвигах |
RDX | 64 бит | Расширенный регистр данных, используется в операциях умножения и деления |
RSI | 64 бит | Источник для строковых операций |
RDI | 64 бит | Приёмник для строковых операций |
RBP | 64 бит | Указатель базы стека (base pointer) |
RSP | 64 бит | Указатель вершины стека (stack pointer) |
Каждый из этих 64-битных регистров имеет младшие части:
- 32-битные:
EAX,EBX,ECX,EDX,ESI,EDI,EBP,ESP - 16-битные:
AX,BX,CX,DX,SI,DI,BP,SP - 8-битные:
AL,AH,BL,BH,CL,CH,DL,DHи т.д.
Дополнительные регистры (R8–R15)
Архитектура x86-64 добавляет восемь новых 64-битных регистров общего назначения:
R8,R9,R10,R11,R12,R13,R14,R15
Их младшие части:
- 32-битные:
R8D,R9D, ...,R15D - 16-битные:
R8W,R9W, ...,R15W - 8-битные:
R8B,R9B, ...,R15B
Сегментные регистры
CS— кодDS— данныеSS— стекES,FS,GS— дополнительные сегменты
В 64-битном режиме сегментация практически отключена, но FS и GS используются для доступа к данным потока или системной информации.
Регистр флагов (RFLAGS)
Регистр флагов содержит битовые индикаторы состояния процессора после выполнения операций:
- CF (Carry Flag) — флаг переноса, устанавливается при переполнении беззнаковой арифметики
- ZF (Zero Flag) — устанавливается, если результат операции равен нулю
- SF (Sign Flag) — устанавливается, если результат отрицателен (старший бит = 1)
- OF (Overflow Flag) — флаг переполнения знаковой арифметики
- PF (Parity Flag) — чётность младшего байта результата
- AF (Auxiliary Carry Flag) — перенос между битами 3 и 4 (используется в BCD-арифметике)
Эти флаги используются условными переходами (JZ, JNZ, JS, JO и др.).
Модели памяти и адресация
Память в x86-64 адресуется линейно, с использованием 64-битных виртуальных адресов. Реальный физический адрес формируется через многоуровневую таблицу страниц, но программист работает с виртуальными адресами.
Способы адресации операндов
-
Непосредственная (immediate)
Значение указано прямо в инструкции:mov eax, 42 -
Регистровая (register)
Операнд находится в регистре:mov ebx, eax -
Прямая (direct memory)
Операнд по фиксированному адресу:mov eax, [0x1000] -
Косвенная (indirect)
Адрес хранится в регистре:mov eax, [rbx] -
Базовая + смещение (base + displacement)
mov eax, [rbx + 8] -
Масштабируемая индексная (scaled index)
mov eax, [rbx + rsi*4] -
База + индекс + смещение
mov eax, [rbx + rsi*4 + 16]
Все эти формы допустимы в x86-64 и позволяют гибко работать с массивами, структурами и стеком.
Стек
Стек — это область памяти, управляемая по принципу LIFO (Last In, First Out). Указатель стека (RSP) всегда указывает на вершину стека.
Основные операции:
push reg/mem/imm— помещает значение в стек, уменьшаяRSPpop reg— извлекает значение из стека, увеличиваяRSP
Стек используется для:
- передачи аргументов функций
- хранения возвращаемых адресов при вызове подпрограмм
- временного хранения регистров
Вызов функций и соглашения о вызовах
В x86-64 на большинстве Unix-подобных систем (Linux, macOS) используется System V AMD64 ABI. В Windows — Microsoft x64 calling convention.
System V AMD64 ABI (Linux/macOS)
- Первые 6 целочисленных аргументов передаются в регистрах:
RDI,RSI,RDX,RCX,R8,R9 - Первые 8 вещественных аргументов — в
XMM0–XMM7 - Дополнительные аргументы — через стек
- Возвращаемое значение — в
RAX(илиRDX:RAXдля 128-битных значений) - Регистры
RBX,RBP,R12–R15— callee-saved (вызывающая функция ожидает, что они сохранятся) - Регистры
RAX,RCX,RDX,R8–R11— caller-saved
Пример вызова функции
mov rdi, 10 ; первый аргумент
mov rsi, 20 ; второй аргумент
call add_numbers ; вызов функции
; результат в RAX
Секции программы
Программа на ассемблере состоит из секций (sections), каждая из которых имеет своё назначение:
.text— исполняемый код (только чтение, исполняемый).data— инициализированные данные (чтение/запись).bss— неинициализированные данные (резервирование памяти).rodata— константные данные (только чтение)
Пример:
section .data
msg db 'Hello, world!', 0xA
msg_len equ $ - msg
section .bss
buffer resb 256
section .text
global _start
_start:
; код программы
Директивы ассемблера
Директивы — это команды для ассемблера, а не для процессора. Они управляют сборкой программы.
Часто используемые директивы (в NASM):
db,dw,dd,dq— определение байтов, слов, двойных слов, четверных словequ— определение константыresb,resw,resd,resq— резервирование памятиglobal— экспортирование метки как точки входаextern— объявление внешней метки (например, из libc)times— повторение данных заданное число раз
Системные вызовы (Linux)
В Linux взаимодействие с ядром происходит через системные вызовы. В x86-64 используется инструкция syscall.
Номер системного вызова помещается в RAX, аргументы — в RDI, RSI, RDX, R10, R8, R9.
Пример: вывод строки
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
mov rsi, msg ; адрес строки
mov rdx, msg_len ; длина
syscall
Завершение программы:
mov rax, 60 ; sys_exit
mov rdi, 0 ; код возврата
syscall
Инструкции процессора
Инструкции — это элементарные команды, которые понимает и исполняет процессор. В архитектуре x86-64 существует несколько сотен инструкций, но повседневное программирование использует ограниченный набор. Все инструкции делятся на логические группы: передача данных, арифметика, логические операции, управление потоком выполнения, работа со стеком, системные вызовы и специальные операции.
Передача данных
Эти инструкции перемещают данные между регистрами, памятью и непосредственными значениями.
MOV — копирование данных
Синтаксис:
mov приёмник, источник
Примеры:
mov eax, 100 ; загрузка константы
mov ebx, eax ; копирование регистра
mov [var], ecx ; запись в память
mov edx, [buffer] ; чтение из памяти
Ограничения:
- Нельзя напрямую копировать из памяти в память:
mov [dst], [src]— недопустимо. - Нельзя загружать непосредственное значение в сегментный регистр (например,
mov ds, 0x10— запрещено в защищённом режиме).
LEA — загрузка эффективного адреса
LEA вычисляет адрес, но не обращается к памяти. Это мощный инструмент для арифметики указателей.
lea rax, [rbx + rcx*4 + 16]
Эта команда помещает в RAX значение RBX + RCX*4 + 16, не читая содержимое по этому адресу. Часто используется для быстрого умножения и сложения.
XCHG — обмен значениями
xchg eax, ebx ; обмен содержимым EAX и EBX
Атомарная операция, полезна в многопоточной среде.
Арифметические операции
Все арифметические инструкции обновляют флаги в RFLAGS.
ADD — сложение
add eax, ebx ; EAX = EAX + EBX
add [counter], 1
SUB — вычитание
sub eax, 5 ; EAX = EAX - 5
INC / DEC — инкремент и декремент
inc eax ; EAX = EAX + 1
dec ebx ; EBX = EBX - 1
Эти инструкции короче по размеру, но не обновляют флаг переноса (CF). В современном коде часто предпочитают add reg, 1 ради единообразия.
MUL / IMUL — умножение
MUL— беззнаковое умножениеIMUL— знаковое умножение
Однооперандная форма:
mul rbx ; RDX:RAX = RAX * RBX
Двухоперандная форма (результат в первом операнде):
imul eax, ebx ; EAX = EAX * EBX
imul eax, ebx, 5 ; EAX = EBX * 5
DIV / IDIV — деление
DIV— беззнаковоеIDIV— знаковое
Деление использует пару регистров RDX:RAX как делимое:
mov rax, 100
xor rdx, rdx ; обнулить RDX (остаток)
mov rbx, 7
div rbx ; RAX = 100 / 7, RDX = 100 % 7
Логические и побитовые операции
AND, OR, XOR, NOT
and eax, 0xFF ; маскирование младшего байта
or ebx, 1 ; установка младшего бита
xor ecx, ecx ; обнуление регистра (быстрее, чем mov ecx, 0)
not edx ; побитовая инверсия
XOR с самим собой — идиома обнуления регистра.
TEST — проверка битов без изменения значения
test eax, eax ; устанавливает ZF, если EAX == 0
jz zero_label
Часто используется вместо cmp eax, 0.
CMP — сравнение
cmp eax, ebx ; вычитает EBX из EAX, обновляет флаги, но не сохраняет результат
je equal_label
Управление потоком выполнения
Безусловный переход — JMP
jmp start_loop
Может быть:
- прямым —
jmp label - косвенным —
jmp rax(переход по адресу в регистре)
Условные переходы
Условные переходы проверяют флаги и выполняют переход, если условие истинно.
| Инструкция | Условие | Описание |
|---|---|---|
JE / JZ | ZF = 1 | равно / ноль |
JNE / JNZ | ZF = 0 | не равно / не ноль |
JL / JNGE | SF ≠ OF | меньше (знаковое) |
JLE / JNG | ZF = 1 или SF ≠ OF | меньше или равно |
JG / JNLE | ZF = 0 и SF = OF | больше |
JGE / JNL | SF = OF | больше или равно |
JB / JNAE | CF = 1 | ниже (беззнаковое) |
JBE / JNA | CF = 1 или ZF = 1 | ниже или равно |
JA / JNBE | CF = 0 и ZF = 0 | выше |
JAE / JNB | CF = 0 | выше или равно |
JS | SF = 1 | отрицательный |
JNS | SF = 0 | неотрицательный |
JO | OF = 1 | переполнение |
JNO | OF = 0 | нет переполнения |
JP / JPE | PF = 1 | чётный паритет |
JNP / JPO | PF = 0 | нечётный паритет |
Циклы
Инструкция LOOP (устаревшая, но иногда используется):
mov rcx, 10
loop_start:
; тело цикла
loop loop_start ; уменьшает RCX, переходит, если RCX ≠ 0
Современный подход — использовать dec + jnz:
mov rcx, 10
loop_start:
; тело
dec rcx
jnz loop_start
Вызов подпрограмм
CALL — вызов функции
call my_function
Выполняет два действия:
- Помещает адрес следующей инструкции в стек (
push rip) - Загружает в
RIPадресmy_function
RET — возврат из функции
ret
Извлекает адрес из стека и присваивает его RIP.
Работа со стеком
PUSH — положить в стек
push rax
push 42
push qword [var]
Уменьшает RSP на 8 (в 64-битном режиме) и записывает значение.
POP — извлечь из стека
pop rbx
Читает значение из стека и увеличивает RSP.
Специальные инструкции
NOP — пустая операция
Занимает один такт, ничего не делает. Используется для выравнивания, отладки, задержек.
HLT — остановка процессора
Останавливает CPU до следующего прерывания. Используется в ядрах ОС.
INT — программное прерывание
В 64-битном режиме почти не используется. Раньше применялось для системных вызовов (int 0x80 в 32-битном Linux).
SYSCALL — современный системный вызов
Как описано ранее, основной способ вызова ядра в x86-64.
Префиксы инструкций
Некоторые инструкции могут иметь префиксы, изменяющие их поведение:
LOCK— обеспечивает атомарность (lock inc [var])REP— повторяет строковую операцию (rep movsb)REPE/REPZ— повторяет, пока ZF = 1REPNE/REPNZ— повторяет, пока ZF = 0
Пример копирования блока памяти:
mov rsi, src
mov rdi, dst
mov rcx, count
cld ; направление вперёд
rep movsb ; копировать RCX байт из [RSI] в [RDI]
Работа с памятью и данными
Эффективная работа с памятью — ключевая компетенция программиста на ассемблере. В отличие от высокоуровневых языков, где управление памятью скрыто за абстракциями, ассемблер предоставляет прямой доступ к байтам, словам и структурам. Эта часть описывает, как организовывать и манипулировать данными: массивы, строки, структуры, выравнивание и динамическое распределение.
Организация данных в памяти
Данные в ассемблере определяются в секциях .data (инициализированные) и .bss (неинициализированные). Каждое определение создаёт символ, который можно использовать как адрес.
Базовые директивы определения данных
| Директива | Размер | Описание |
|---|---|---|
db | 1 байт | Define Byte |
dw | 2 байта | Define Word |
dd | 4 байта | Define Doubleword |
dq | 8 байт | Define Quadword |
dt | 10 байт | Define Ten-byte (для расширенных вещественных чисел, редко используется) |
Примеры:
section .data
byte_val db 42
word_val dw 1000
dword_val dd 0x12345678
qword_val dq 9223372036854775807
pi dd 3.141592 ; вещественное число (IEEE 754)
Массивы
Массив — это последовательность элементов одного типа, расположенных подряд в памяти.
Однобайтовый массив:
digits db 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
Массив слов:
scores dw 100, 200, 300, 400
Массив из 100 нулей:
buffer db 100 dup(0)
В NASM директива dup позволяет повторить значение заданное число раз.
Строки
Строка в ассемблере — это массив байтов, завершённый нулём (C-стиль) или имеющий известную длину.
Примеры строк:
msg1 db 'Hello', 0 ; C-строка (с нулевым терминатором)
msg2 db 'World!', 10 ; строка с символом новой строки
msg3 db "Quoted text", 0 ; допустимы двойные кавычки
Длина строки часто вычисляется с помощью метки:
hello db 'Hello, world!', 0xA, 0
hello_len equ $ - hello ; $ — текущая позиция, вычитаем начало
Значение hello_len будет равно 14 (13 символов + \n).
Доступ к элементам массива
Доступ к элементу массива осуществляется через адресацию с масштабированием.
Однобайтовый массив
mov al, [digits + 3] ; AL = 3
Массив слов (2 байта)
mov ax, [scores + 2*1] ; второй элемент (индекс 1): 200
Массив двойных слов (4 байта)
mov eax, [array_dd + 4*2] ; третий элемент
Общая формула:
адрес = база + индекс * размер_элемента
В x86-64 можно использовать гибкую адресацию:
mov eax, [rbx + rsi*4] ; RBX — база, RSI — индекс, 4 — размер элемента
Структуры данных
Ассемблер не имеет встроенного типа «структура», но можно моделировать её через смещения.
Пример: структура точки на плоскости
struc Point
.x resd 1 ; 4 байта для x
.y resd 1 ; 4 байта для y
endstruc
Использование:
section .data
origin Point 0, 0
p1 Point 10, 20
section .text
mov eax, [p1 + Point.x] ; EAX = 10
mov ebx, [p1 + Point.y] ; EBX = 20
Если ассемблер не поддерживает struc (например, в GAS), смещения определяются вручную:
POINT_X equ 0
POINT_Y equ 4
mov eax, [p1 + POINT_X]
Выравнивание данных
Процессор эффективнее читает данные, выровненные по границам, кратным их размеру:
- 2-байтовые — по чётным адресам
- 4-байтовые — по адресам, кратным 4
- 8-байтовые — по адресам, кратным 8
NASM предоставляет директиву align:
section .data
align 8
counter dq 0
Выравнивание особенно важно для SIMD-инструкций (SSE, AVX), требующих 16- или 32-байтное выравнивание.
Работа со строками
Строковые операции ускоряются специальными инструкциями:
| Инструкция | Действие |
|---|---|
MOVSB | копирует байт из [RSI] в [RDI], увеличивает/уменьшает RSI/RDI |
MOVSW | копирует слово |
MOVSD | копирует двойное слово |
MOV SQ | копирует четверное слово |
STOSB | записывает AL в [RDI] |
LODSB | загружает байт из [RSI] в AL |
CMPSB | сравнивает байты в [RSI] и [RDI] |
SCASB | сравнивает AL с [RDI] |
Направление изменения указателей задаётся флагом DF:
CLD— очистка DF, направление вперёд (RSI++,RDI++)STD— установка DF, направление назад (RSI--,RDI--)
Пример: поиск символа в строке
mov rdi, msg
mov al, '!'
mov rcx, msg_len
cld
repne scasb ; ищет '!' в строке
jnz not_found ; если ZF=0 — не найдено
; RDI указывает на символ после '!'
Динамическое выделение памяти
В пользовательском режиме под Linux динамическая память запрашивается через системный вызов mmap или через библиотечную функцию malloc.
Пример с malloc (через libc):
extern malloc
extern free
section .text
mov rdi, 1024 ; запрашиваем 1024 байта
call malloc ; возвращает указатель в RAX
test rax, rax
jz alloc_failed
; используем память по адресу RAX
mov [rax], byte 42
; освобождение
mov rdi, rax
call free
Чистый системный вызов mmap:
; mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
mov rax, 9 ; sys_mmap
mov rdi, 0 ; addr
mov rsi, 4096 ; length
mov rdx, 3 ; prot: read|write
mov r10, 0x22 ; flags: MAP_PRIVATE|MAP_ANONYMOUS
mov r8, -1 ; fd
mov r9, 0 ; offset
syscall
; RAX = адрес или -1 при ошибке
Управление памятью в стеке
Локальные переменные часто размещаются в стеке:
sub rsp, 16 ; выделить 16 байт под локальные данные
mov [rsp], rax ; сохранить RAX
mov [rsp + 8], rbx ; сохранить RBX
; ... работа ...
add rsp, 16 ; освободить
Это безопасно, так как стек автоматически «растёт» вниз, и ОС гарантирует наличие страницы-сторожа.
Константы и псевдонимы
Константы определяются через equ:
MAX_SIZE equ 1000
FLAG_ON equ 1
FLAG_OFF equ 0
Они заменяются на этапе сборки и не занимают места в памяти.
Функции, рекурсия и организация стекового фрейма
Функции (подпрограммы) — основной способ структурирования кода даже в ассемблере. Они позволяют повторно использовать логику, изолировать задачи и управлять сложностью программы. В отличие от высокоуровневых языков, где вызов функции выглядит как простая конструкция, в ассемблере программист полностью отвечает за передачу аргументов, сохранение контекста, управление стеком и возврат результата.
Основы вызова функции
Вызов функции в x86-64 выполняется с помощью инструкции call, которая автоматически помещает адрес возврата в стек и передаёт управление на метку функции. Завершение функции происходит через ret, которая извлекает адрес возврата из стека и продолжает выполнение.
Пример простой функции:
section .text
global _start
_start:
mov rdi, 5
call factorial
; результат в RAX
mov rdi, rax
mov rax, 60 ; sys_exit
syscall
factorial:
cmp rdi, 1
jle .base_case
push rdi ; сохраняем текущий n
dec rdi
call factorial ; рекурсивный вызов
pop rbx ; восстанавливаем n
imul rax, rbx ; RAX = RAX * n
ret
.base_case:
mov rax, 1
ret
Эта функция вычисляет факториал числа, переданного в RDI, и возвращает результат в RAX.
Стековый фрейм (stack frame)
Стековый фрейм — это область стека, выделенная под одну активацию функции. Он содержит:
- адрес возврата
- сохранённые регистры
- локальные переменные
- аргументы (если передаются через стек)
В x86-64 принято использовать RBP как указатель базы фрейма (frame pointer), хотя современные компиляторы часто его опускают для оптимизации.
Типичная пролога и эпилога функции
Пролога (начало функции):
push rbp ; сохраняем старый RBP
mov rbp, rsp ; устанавливаем новый RBP = текущий RSP
sub rsp, 32 ; выделяем 32 байта под локальные переменные
Эпилога (конец функции):
mov rsp, rbp ; восстанавливаем RSP
pop rbp ; восстанавливаем старый RBP
ret
После установки RBP, смещения относительно него имеют фиксированный смысл:
[rbp + 8]— адрес возврата[rbp + 16]— первый аргумент, переданный через стек[rbp]— предыдущий RBP[rbp - 8],[rbp - 16]— локальные переменные
Пример с фреймом:
my_function:
push rbp
mov rbp, rsp
sub rsp, 16 ; два 8-байтовых локальных слова
mov [rbp - 8], rdi ; сохранили первый аргумент
mov [rbp - 16], rsi ; сохранили второй аргумент
; ... вычисления ...
mov rax, [rbp - 8] ; загрузили результат
mov rsp, rbp
pop rbp
ret
Передача аргументов
Согласно System V AMD64 ABI, первые шесть целочисленных аргументов передаются в регистрах:
RDI,RSI,RDX,RCX,R8,R9
Если аргументов больше шести, остальные размещаются в стеке справа налево (последний аргумент — ближе к вершине стека).
Пример функции с семью аргументами:
; C-прототип: int func(a, b, c, d, e, f, g)
; a → RDI, b → RSI, ..., f → R9, g → [RSP + 8]
func:
; g доступен как [rbp + 16], если используется RBP
mov rax, [rsp + 8] ; или напрямую через RSP
add rax, rdi
ret
Возвращаемые значения
- Целые числа и указатели возвращаются в
RAX - 128-битные значения — в
RDX:RAX - Вещественные числа — в
XMM0
Сохранение регистров (callee-saved vs caller-saved)
ABI определяет, какие регистры обязан сохранять вызываемая функция (callee-saved), а какие — вызывающая (caller-saved).
Callee-saved (функция обязана сохранить и восстановить):
RBX,RBP,R12–R15
Caller-saved (вызывающая сторона не ожидает их сохранения):
RAX,RCX,RDX,RSI,RDI,R8–R11
Если функция использует RBX, она должна сохранить его в начале и восстановить перед ret:
my_func:
push rbx
; ... используем RBX ...
pop rbx
ret
Рекурсия
Рекурсия в ассемблере работает так же, как и в других языках: функция вызывает саму себя. Каждый вызов создаёт новый стековый фрейм, что позволяет хранить независимые копии аргументов и локальных переменных.
Условие завершения (базовый случай) обязательно, иначе стек переполнится (stack overflow).
Пример: вычисление суммы чисел от 1 до n
sum_to_n:
cmp rdi, 0
je .base
push rdi
dec rdi
call sum_to_n
pop rbx
add rax, rbx
ret
.base:
xor rax, rax
ret
Хвостовая рекурсия и оптимизация
Хвостовая рекурсия — это вызов функции в самом конце, без дополнительных операций после возврата. Такой вызов можно заменить на цикл, избегая роста стека.
Пример хвостовой рекурсии:
; sum_tail(n, acc)
sum_tail:
cmp rdi, 0
je .done
add rsi, rdi ; acc += n
dec rdi ; n--
jmp sum_tail ; хвостовой вызов → заменён на JMP
.done:
mov rax, rsi
ret
Здесь jmp вместо call предотвращает накопление фреймов.
Локальные массивы и структуры
Большие локальные данные также размещаются в стеке:
process_data:
push rbp
mov rbp, rsp
sub rsp, 256 ; 256 байт под буфер
lea rax, [rbp - 256] ; RAX = адрес начала буфера
mov [rax], byte 0
; ... обработка ...
mov rsp, rbp
pop rbp
ret
Важно: стек ограничен (обычно 8 МБ в Linux), поэтому очень большие массивы следует выделять динамически.
Обработка ошибок и возврат статусов
Функции могут возвращать коды ошибок через RAX. Например, -1 означает ошибку, 0 — успех.
safe_divide:
test rsi, rsi ; делитель = 0?
jz .error
mov rax, rdi
xor rdx, rdx
div rsi
ret
.error:
mov rax, -1
ret
Взаимодействие с C
Ассемблерные функции можно вызывать из C, если соблюдать ABI.
Пример объявления в C:
extern long my_asm_func(long a, long b);
Ассемблерная реализация:
global my_asm_func
my_asm_func:
add rdi, rsi
mov rax, rdi
ret
Сборка:
nasm -f elf64 func.asm -o func.o
gcc main.c func.o -o program
Взаимодействие с операционной системой
Программа на ассемблере не существует в вакууме. Чтобы читать файлы, выводить текст, получать ввод от пользователя или завершать выполнение, она должна взаимодействовать с операционной системой. В Unix-подобных системах (Linux, macOS) это происходит через системные вызовы — специальные точки входа в ядро. В Windows используется другая модель (WinAPI), но в рамках данного справочника фокус сделан на Linux x86-64, как наиболее доступной и документированной среде для обучения.
Механизм системных вызовов в x86-64
В 64-битном режиме Linux системные вызовы выполняются через инструкцию syscall. Процессор переключается в привилегированный режим, управление передаётся ядру, которое выполняет запрошенную операцию и возвращает результат.
Регистры для системных вызовов (System V ABI)
| Регистр | Назначение |
|---|---|
RAX | номер системного вызова |
RDI | 1-й аргумент |
RSI | 2-й аргумент |
RDX | 3-й аргумент |
R10 | 4-й аргумент |
R8 | 5-й аргумент |
R9 | 6-й аргумент |
Важно: четвёртый аргумент передаётся в
R10, а не вRCX, потому чтоsyscallразрушает значениеRCX.
Результат возвращается в RAX:
- неотрицательное значение — успешный результат
- отрицательное значение — код ошибки (
-errno)
Основные системные вызовы
1. sys_write — запись данных
Номер: 1
Сигнатура: ssize_t write(int fd, const void *buf, size_t count)
Пример: вывод строки в стандартный вывод
section .data
msg db 'Hello from assembly!', 0xA
msg_len equ $ - msg
section .text
global _start
_start:
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
mov rsi, msg ; адрес буфера
mov rdx, msg_len ; количество байт
syscall
2. sys_read — чтение данных
Номер: 0
Сигнатура: ssize_t read(int fd, void *buf, size_t count)
Пример: чтение одной строки с клавиатуры
section .bss
input resb 256
section .text
; ...
mov rax, 0 ; sys_read
mov rdi, 0 ; stdin
mov rsi, input ; буфер
mov rdx, 256 ; максимум байт
syscall
; RAX = количество прочитанных байт
3. sys_exit — завершение программы
Номер: 60
Сигнатура: void _exit(int status)
mov rax, 60
mov rdi, 0 ; код возврата
syscall
4. sys_open — открытие файла
Номер: 2
Сигнатура: int open(const char *pathname, int flags, mode_t mode)
Флаги (часто используемые):
O_RDONLY= 0O_WRONLY= 1O_RDWR= 2O_CREAT= 0x40O_TRUNC= 0x200
Режим (если создаётся файл): 0o644 (восьмеричное) → 0x1A4
Пример: открытие файла для чтения
section .data
filename db 'data.txt', 0
section .text
mov rax, 2 ; sys_open
mov rdi, filename ; путь
mov rsi, 0 ; O_RDONLY
mov rdx, 0 ; режим не нужен при чтении
syscall
; RAX = дескриптор файла или -1 при ошибке
5. sys_close — закрытие файла
Номер: 3
Сигнатура: int close(int fd)
mov rax, 3
mov rdi, r12 ; предположим, дескриптор в R12
syscall
6. sys_lseek — перемещение указателя в файле
Номер: 8
Сигнатура: off_t lseek(int fd, off_t offset, int whence)
whence:
SEEK_SET= 0 (от начала)SEEK_CUR= 1 (от текущей позиции)SEEK_END= 2 (от конца)
Пример: перейти в конец файла
mov rax, 8
mov rdi, fd
mov rsi, 0
mov rdx, 2 ; SEEK_END
syscall
7. sys_mmap — отображение файла в память
Номер: 9
Позволяет отобразить файл или выделить анонимную память.
Пример: выделение 4 КБ анонимной памяти
mov rax, 9
mov rdi, 0 ; addr (NULL)
mov rsi, 4096 ; length
mov rdx, 3 ; PROT_READ | PROT_WRITE
mov r10, 0x22 ; MAP_PRIVATE | MAP_ANONYMOUS
mov r8, -1 ; fd = -1
mov r9, 0 ; offset
syscall
; RAX = адрес или -1
Обработка ошибок
После системного вызова проверяйте знак результата:
syscall
cmp rax, 0
jl .error_handler
Код ошибки можно получить как -rax и использовать для диагностики (например, -2 = ENOENT — файл не найден).
Работа с несколькими файлами
Пример: копирование содержимого одного файла в другой
; Открыть исходный файл
mov rax, 2
mov rdi, src_name
mov rsi, 0
syscall
mov r12, rax ; сохраняем дескриптор
; Открыть целевой файл (создать, если нет)
mov rax, 2
mov rdi, dst_name
mov rsi, 0x441 ; O_CREAT | O_WRONLY | O_TRUNC
mov rdx, 0o644
syscall
mov r13, rax
; Чтение и запись в цикле
.read_loop:
mov rax, 0
mov rdi, r12
mov rsi, buffer
mov rdx, 1024
syscall
test rax, rax
jle .done ; <= 0 — конец или ошибка
mov rbx, rax ; сохраняем количество байт
mov rax, 1
mov rdi, r13
mov rsi, buffer
mov rdx, rbx
syscall
jmp .read_loop
.done:
; Закрыть оба файла
mov rax, 3
mov rdi, r12
syscall
mov rax, 3
mov rdi, r13
syscall
Сигналы
Ассемблер позволяет устанавливать обработчики сигналов через sys_rt_sigaction (номер 13), но это сложная тема. В простых программах сигналы обычно игнорируются или обрабатываются по умолчанию (например, SIGINT завершает программу).
Процессы и потоки
sys_fork(57) — создаёт новый процессsys_execve(59) — заменяет текущий образ памяти новой программойsys_clone(56) — создаёт поток (низкоуровневый аналогpthread_create)
Пример запуска другой программы:
section .data
prog db '/bin/ls', 0
argv dq prog, 0
envp dq 0
section .text
mov rax, 59 ; sys_execve
mov rdi, prog
mov rsi, argv
mov rdx, envp
syscall
; Если execve вернулся — произошла ошибка
Время и задержки
sys_nanosleep(35) — приостанавливает выполнение
section .data
ts dq 1, 500000000 ; 1 секунда + 500 млн наносекунд = 1.5 сек
section .text
mov rax, 35
mov rdi, ts
mov rsi, 0
syscall
Использование libc вместо прямых системных вызовов
Для сложных задач (например, форматированный вывод) удобнее вызывать функции из стандартной библиотеки C (printf, fopen, malloc и т.д.).
Пример с printf:
extern printf
extern exit
section .data
fmt db 'Result: %ld', 10, 0
section .text
global main
main:
push rbp
mov rbp, rsp
mov rdi, fmt
mov rsi, 42
call printf
mov rdi, 0
call exit
Сборка:
nasm -f elf64 program.asm -o program.o
gcc program.o -o program
Преимущества libc:
- удобство (
printf,scanf) - портируемость
- обработка ошибок
- работа с локалью
Недостатки:
- зависимость от библиотеки
- больший размер исполняемого файла
- менее прямой контроль
Отладка, анализ и инструменты
Написание программ на ассемблере требует тесного взаимодействия с инструментами сборки, анализа и отладки. В отличие от высокоуровневых языков, где ошибки часто ловятся на этапе компиляции или через исключения, в ассемблере даже небольшая ошибка в адресации или управлении стеком может привести к молчаливому повреждению памяти или аварийному завершению. Поэтому знание инструментов — не дополнение, а необходимость.
Основной инструментарий для x86-64 (Linux)
| Инструмент | Назначение |
|---|---|
| NASM | Ассемблер (Netwide Assembler) — преобразует .asm в объектные файлы |
| GAS | Ассемблер из GNU Binutils, использует AT&T-синтаксис |
| LD | Компоновщик (linker) — объединяет объектные файлы в исполняемый |
| GCC | Может выступать как компоновщик, автоматически подключая libc |
| GDB | Отладчик — позволяет шагать по инструкциям, проверять регистры и память |
| objdump | Дизассемблер — показывает машинный код и символы |
| strace | Трассировщик системных вызовов |
| ltrace | Трассировщик вызовов библиотечных функций |
| readelf | Анализ ELF-файлов (заголовки, секции, символы) |
Сборка программы без libc (чистый системный вызов)
Файл hello.asm:
section .data
msg db 'Hello, world!', 0xA
len equ $ - msg
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, len
syscall
mov rax, 60
mov rdi, 0
syscall
Сборка:
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
Получаем минимальный исполняемый файл (~300–500 байт).
Сборка с использованием libc
Файл hello_libc.asm:
extern printf
extern exit
section .data
fmt db 'Hello from libc!', 10, 0
section .text
global main
main:
push rbp
mov rbp, rsp
mov rdi, fmt
xor rax, rax ; количество векторных регистров = 0
call printf
mov rdi, 0
call exit
Сборка:
nasm -f elf64 hello_libc.asm -o hello_libc.o
gcc hello_libc.o -o hello_libc
Здесь gcc автоматически вызывает ld, подключает libc, crt0.o и другие необходимые компоненты.
Отладка с помощью GDB
GDB — мощный отладчик, поддерживающий низкоуровневую работу.
Запуск отладки
gdb ./hello
Основные команды
| Команда | Действие |
|---|---|
layout asm | Показать ассемблерный код в реальном времени |
break _start | Установить точку останова |
run | Запустить программу |
stepi (si) | Выполнить одну инструкцию |
nexti (ni) | Выполнить инструкцию, не заходя в вызовы |
info registers (i r) | Показать все регистры |
print/x $rax | Показать RAX в шестнадцатеричном виде |
x/10xb $rsp | Показать 10 байт памяти по адресу RSP |
disassemble | Дизассемблировать текущую функцию |
quit | Выйти |
Пример сессии
(gdb) break _start
(gdb) run
(gdb) layout asm
(gdb) stepi
(gdb) i r rax rdi rsi rdx
(gdb) x/s $rsi ; показать строку по адресу RSI
GDB особенно полезен при отладке переполнения стека, неправильной адресации или ошибок системных вызовов.
Анализ исполняемого файла: objdump и readelf
objdump — дизассемблирование
objdump -d hello
Выводит машинный код и соответствующие ассемблерные инструкции:
0000000000401000 <_start>:
401000: 48 c7 c0 01 00 00 00 mov $0x1,%rax
401007: 48 c7 c7 01 00 00 00 mov $0x1,%rdi
...
Опции:
-d— дизассемблировать исполняемые секции-t— показать таблицу символов-s— показать содержимое секций в hex
readelf — анализ структуры ELF
readelf -h hello # заголовок ELF
readelf -S hello # секции
readelf -s hello # символы
Позволяет убедиться, что секции .text, .data присутствуют, точки входа корректны.
Трассировка выполнения: strace и ltrace
strace — отслеживание системных вызовов
strace ./hello
Вывод:
write(1, "Hello, world!\n", 14) = 14
exit(0) = ?
+++ exited with 0 +++
Полезно для:
- проверки, какие файлы открываются
- диагностики ошибок (
-1 ENOENT) - понимания поведения программы без исходного кода
ltrace — отслеживание вызовов libc
ltrace ./hello_libc
Вывод:
printf("Hello from libc!\n") = 18
exit(0) = ?
Используется, когда программа активно применяет стандартную библиотеку.
Поиск ошибок: типичные проблемы и решения
1. Segmentation fault (ошибка сегментации)
Причины:
- обращение по недопустимому адресу (
mov eax, [0]) - запись в
.text-секцию - порча стека (
popбезpush, непарныйret)
Решение:
- запустить под
gdb, выполнить до падения, проверить регистры и стек - использовать
strace, чтобы увидеть последний системный вызов
2. Программа завершается, но ничего не выводит
Проверьте:
- правильно ли задана длина строки (не забыт ли
\nили терминатор) - используется ли
stdout(fd=1), а неstderr(fd=2) - не перепутаны
RDXиRSIвsys_write
3. Стек не выровнен (ошибка при вызове libc)
При вызове функций из libc стек должен быть выровнен по 16-байтной границе перед call.
Если в main вы делаете push rbp, стек смещается на 8 байт. Чтобы выровнять:
main:
push rbp
mov rbp, rsp
sub rsp, 8 ; теперь RSP % 16 == 0
; ... вызовы printf ...
add rsp, 8
pop rbp
ret
Или используйте чётное количество push.
4. Неправильный номер системного вызова
Номера отличаются между архитектурами. Для x86-64 Linux актуальные номера можно найти в:
/usr/include/asm/unistd_64.h- онлайн-справочниках (например,
syscalls.kernelgrok.com)
Автоматизация: Makefile
Для проектов с несколькими файлами удобно использовать Makefile:
ASM = nasm
CFLAGS = -f elf64
LD = ld
CC = gcc
all: hello pure_hello
hello: hello_libc.o
$(CC) $< -o $@
pure_hello: hello.o
$(LD) $< -o $@
%.o: %.asm
$(ASM) $(CFLAGS) $< -o $@
clean:
rm -f *.o hello pure_hello
Команда make соберёт обе версии программы.
Практические примеры
Теория становится полезной, когда применяется на практике. В этой заключительной части представлены законченные, рабочие примеры программ на ассемблере для x86-64 Linux. Каждый пример демонстрирует ключевые концепции: управление памятью, арифметика, рекурсия, работа с файлами, обработка ввода и оптимизация. Все программы написаны в синтаксисе NASM и могут быть собраны стандартными инструментами.
Пример 1: Рекурсивный факториал (чистый системный вызов)
Эта программа вычисляет факториал числа, переданного как аргумент командной строки (упрощённо — фиксированное значение), и выводит результат в виде десятичного числа.
section .data
result_msg db 'Factorial: ', 0
newline db 10
section .bss
buffer resb 20
section .text
global _start
; Функция: преобразует число в строку (десятичное)
; вход: RDI = число, RSI = буфер (заполняется задом наперёд)
; выход: RAX = длина строки
itoa:
mov rax, rdi
mov rdi, rsi
add rdi, 19 ; указываем на конец буфера
mov byte [rdi], 0 ; нулевой терминатор
dec rdi
mov rbx, 10
cmp rax, 0
jnz .loop
mov byte [rdi], '0'
dec rdi
jmp .done
.loop:
xor rdx, rdx
div rbx
add dl, '0'
mov [rdi], dl
dec rdi
test rax, rax
jnz .loop
.done:
inc rdi
mov rax, rsi
sub rax, rdi
neg rax
ret
; Функция: факториал
; вход: RDI = n
; выход: RAX = n!
factorial:
cmp rdi, 1
jle .base
push rdi
dec rdi
call factorial
pop rbx
imul rax, rbx
ret
.base:
mov rax, 1
ret
_start:
mov rdi, 6 ; вычисляем 6!
call factorial
lea rsi, [buffer]
mov rdi, rax
call itoa ; RAX = длина
; Вывод "Factorial: "
mov rax, 1
mov rdi, 1
mov rsi, result_msg
mov rdx, 11
syscall
; Вывод числа
mov rax, 1
mov rdi, 1
lea rsi, [buffer]
; длина уже в RAX
syscall
; Новая строка
mov rax, 1
mov rdi, 1
mov rsi, newline
mov rdx, 1
syscall
; Завершение
mov rax, 60
mov rdi, 0
syscall
Особенности:
- Ручная конвертация числа в строку (
itoa) - Рекурсивный вызов с сохранением контекста
- Чистый вывод через
sys_write
Пример 2: Обход массива и поиск максимума
Программа ищет максимальное значение в статическом массиве и выводит его.
section .data
arr dq 15, -3, 42, 7, 29, -10, 100
arr_len equ ($ - arr) / 8
msg db 'Max: ', 0
section .bss
num_buf resb 20
section .text
global _start
; Подпрограмма itoa (как в предыдущем примере, сокращена)
; ... (вставьте itoa здесь или вынесите в отдельный файл) ...
find_max:
mov rcx, arr_len
mov rsi, arr
mov rax, [rsi] ; первый элемент — текущий максимум
dec rcx
jz .done
.next:
add rsi, 8
mov rdx, [rsi]
cmp rdx, rax
jle .skip
mov rax, rdx
.skip:
loop .next
.done:
ret
print_str:
mov rax, 1
mov rdi, 1
syscall
ret
_start:
call find_max ; RAX = максимум
lea rsi, [num_buf]
mov rdi, rax
call itoa
; Вывод "Max: "
mov rdx, 5
mov rsi, msg
call print_str
; Вывод числа
mov rdx, rax
lea rsi, [num_buf]
call print_str
; Новая строка
mov rdx, 1
mov rsi, newline
call print_str
mov rax, 60
mov rdi, 0
syscall
Особенности:
- Использование
loopдля итерации - Сравнение знаковых чисел
- Модульный вывод через подпрограмму
Пример 3: Чтение файла и подсчёт байтов
Программа открывает файл, читает его по блокам и выводит общий размер.
section .data
filename db 'input.txt', 0
size_msg db 'File size: ', 0
section .bss
buffer resb 4096
num_buf resb 20
section .text
global _start
; itoa и print_str — как выше ...
_start:
; Открыть файл
mov rax, 2
mov rdi, filename
mov rsi, 0 ; O_RDONLY
mov rdx, 0
syscall
mov r12, rax ; fd
cmp rax, 0
jl .error
xor r13, r13 ; total = 0
.read_loop:
mov rax, 0
mov rdi, r12
mov rsi, buffer
mov rdx, 4096
syscall
test rax, rax
jle .done
add r13, rax
jmp .read_loop
.done:
mov rax, 3
mov rdi, r12
syscall ; close
; Вывод результата
mov rdx, 11
mov rsi, size_msg
call print_str
lea rsi, [num_buf]
mov rdi, r13
call itoa
mov rdx, rax
lea rsi, [num_buf]
call print_str
mov rsi, newline
mov rdx, 1
call print_str
mov rax, 60
mov rdi, 0
syscall
.error:
mov rax, 60
mov rdi, 1 ; exit code 1
syscall
Особенности:
- Работа с файловыми дескрипторами
- Циклическое чтение большого файла
- Обработка ошибок открытия
Пример 4: Оптимизированный цикл — сумма массива
Сравним два подхода: через loop и через dec/jnz.
; Метод 1: loop (медленнее на современных CPU)
sum_loop:
mov rcx, len
mov rsi, arr
xor rax, rax
.add:
add rax, [rsi]
add rsi, 8
loop .add
ret
; Метод 2: dec + jnz (быстрее)
sum_fast:
mov rcx, len
mov rsi, arr
xor rax, rax
.add:
add rax, [rsi]
add rsi, 8
dec rcx
jnz .add
ret
На современных процессорах loop не оптимизирован так же хорошо, как dec/jnz, поэтому второй вариант предпочтителен.
Пример 5: Использование SIMD (SSE) для ускорения
Сложение массива с использованием 128-битных регистров:
section .data
align 16
vec_arr dd 1,2,3,4,5,6,7,8
section .text
sum_sse:
pxor xmm0, xmm0 ; обнулить аккумулятор
mov rsi, vec_arr
mov rcx, 2 ; 8 элементов / 4 = 2 итерации
.loop:
movdqa xmm1, [rsi] ; загрузить 4 int32
paddd xmm0, xmm1 ; сложить
add rsi, 16
loop .loop
; Горизонтальное сложение
movhlps xmm1, xmm0
paddd xmm0, xmm1
pshufd xmm1, xmm0, 1
paddd xmm0, xmm1
movd eax, xmm0 ; результат в EAX
ret
Требования:
- Данные выровнены по 16 байтам
- Поддержка SSE у процессора